/* * Copyright 2013-2017 Simba Open Source * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ package org.simbasecurity.core.jaas.loginmodule; import org.owasp.esapi.Encoder; import org.owasp.esapi.reference.DefaultEncoder; import org.simbasecurity.common.constants.AuthenticationConstants; import org.simbasecurity.core.config.SimbaConfigurationParameter; import org.simbasecurity.core.config.ConfigurationService; import org.simbasecurity.core.domain.Group; import org.simbasecurity.core.domain.User; import org.simbasecurity.core.domain.repository.GroupRepository; import org.simbasecurity.core.domain.repository.UserRepository; import org.simbasecurity.core.locator.GlobalContext; import javax.naming.Context; import javax.naming.NamingEnumeration; import javax.naming.NamingException; import javax.naming.directory.Attribute; import javax.naming.directory.Attributes; import javax.naming.directory.SearchControls; import javax.naming.directory.SearchResult; import javax.naming.ldap.InitialLdapContext; import javax.naming.ldap.LdapContext; import javax.security.auth.Subject; import javax.security.auth.callback.*; import javax.security.auth.login.FailedLoginException; import javax.security.auth.login.LoginException; import java.util.Hashtable; import java.util.Map; /** * This {@link javax.security.auth.spi.LoginModule LoginModule} performs Active Directory based authentication. A * username and password is verified against the corresponding user credentials stored in an Active Directory. This * module requires the supplied {@link javax.security.auth.callback.CallbackHandler CallbackHandler} to support a * {@link javax.security.auth.callback.NameCallback NameCallback} and a * {@link javax.security.auth.callback.PasswordCallback PasswordCallback}. * <p/> * The following options are mandatory and must be specified in this module's login {@link * javax.security.auth.login.Configuration Configuration}: * <dl><dt></dt><dd> * <dl><dt><code>primaryServer=<b><server>[:<port>]</b></code></dt> * <dd> This option identifies the primary Active Directory server that stores the user entries. Configuring the port * is optional. If the port is not specified the ldap default port number (389) is used.</dd> * <p/> * <dt><code>authDomain=<b>domain</b></code></dt> * <dd> This option specifies the user's domain to authenticate against. </dd> * <p/> * <dt><code>baseDN=<b>ldap_query</b></code></dt> * <dd> This option specifies the base directory node from which to start searching for a user. </dd> * <p/> * <dt><code>filter=<b>ldap_filter</b></code></dt> * <dd> This option specifies the ldap filter to use when searching the user's directory entry. </dd> * <p/> * <dt><code>searchScope=<b>scope</b></code></dt> * <dd> This option specifies the search scope. The scope can be one of: <code>"subtree"</code>, <code>"object"</code> * or <code>"onelevel"</code>. * <p/> * <dt><code>authAttr=<b>attribute</b></code></dt> * <dd> This option specifies the attribute to retrieve from the user's directory entry. </dd> * </dl></dl> * <p/> * This module also recognizes the following optional {@link javax.security.auth.login.Configuration Configuration} * options: * <dl><dt></dt><dd> * <dl><dt><code>secondaryServer=<b><server>[:<port>]</b></code></dt> * <dd> This option identifies the secondary Active Directory server that stores the user entries. Configuring the port * is optional. If the port is not specified the ldap default port number (389) is used.</dd> * <p/> * <dt><code>securityLevel=<b>level</b></code></dt> * <dd> This options specifies the security level to use. The level should be one of the following: <code>"none"</code>, * <code>"simple"</code> or <code>"strong"</code>. If this property is unspecified, the behaviour is determined by * the service provider. </dd> * <p/> * <dt><code>debug=<b>boolean</b></code></dt> * <dd> if <code>true</code>, debug messages are displayed using standard logging at * {@link java.util.logging.Level#FINE FINE} level. </dd> * </dl></dl> * * @since 1.0 */ public class ActiveDirectoryLoginModule extends SimbaLoginModule { private static final String KEY_SEARCH_FILTER = "filter"; private static final String KEY_AUTHENTICATION_ATTR = "authAttr"; private static final String KEY_AUTHENTICATION_DOMAIN = "authDomain"; private static final String KEY_PRIMARY_SERVER = "primaryServer"; private static final String KEY_BASE_DN = "baseDN"; private static final String KEY_SECONDARY_SERVER = "secondaryServer"; private static final String KEY_SECURITY_LEVEL = "securityLevel"; private static final String KEY_SEARCH_SCOPE = "searchScope"; private static final int NAME_CALLBACK = 0; private static final int PASSWORD_CALLBACK = 1; private Callback[] callbacks; private String primaryServerHost; private int primaryServerPort; private String secondaryServerHost; private int secondaryServerPort; private String authenticationDomain; private String searchBase; private String authenticationAttribute; private String searchFilter; private String securityLevel; private int searchScope; public ActiveDirectoryLoginModule() { super(); callbacks = new Callback[2]; callbacks[NAME_CALLBACK] = new NameCallback(AuthenticationConstants.USERNAME); callbacks[PASSWORD_CALLBACK] = new PasswordCallback(AuthenticationConstants.PASSWORD, false); } @Override public void initialize(Subject subject, CallbackHandler callbackHandler, Map<String, ?> sharedState, Map<String, ?> options) { super.initialize(subject, callbackHandler, sharedState, options); primaryServerHost = (String) options.get(KEY_PRIMARY_SERVER); primaryServerPort = 389; int colonIndex = primaryServerHost.indexOf(':'); if (colonIndex != -1) { primaryServerPort = Integer.parseInt(primaryServerHost.substring(colonIndex + 1)); primaryServerHost = primaryServerHost.substring(0, colonIndex); } secondaryServerHost = (String) options.get(KEY_SECONDARY_SERVER); if (secondaryServerHost != null) { secondaryServerPort = 389; colonIndex = secondaryServerHost.indexOf(':'); if (colonIndex != -1) { secondaryServerPort = Integer.parseInt(secondaryServerHost.substring(colonIndex + 1)); secondaryServerHost = secondaryServerHost.substring(0, colonIndex); } } authenticationDomain = (String) options.get(KEY_AUTHENTICATION_DOMAIN); searchBase = (String) options.get(KEY_BASE_DN); authenticationAttribute = (String) options.get(KEY_AUTHENTICATION_ATTR); searchFilter = (String) options.get(KEY_SEARCH_FILTER); securityLevel = (String) options.get(KEY_SECURITY_LEVEL); String scope = (String) options.get(KEY_SEARCH_SCOPE); if (scope == null || "subtree".equalsIgnoreCase(scope)) { searchScope = SearchControls.SUBTREE_SCOPE; } else if ("object".equalsIgnoreCase(scope)) { searchScope = SearchControls.OBJECT_SCOPE; } else if ("onelevel".equalsIgnoreCase(scope)) { searchScope = SearchControls.ONELEVEL_SCOPE; } else { debug("Invalid search scope provided. Using sub-tree scope"); } } private void updateUserGroups(LdapContext ldapContext, String userCN) { UserRepository userRepository = GlobalContext.locate(UserRepository.class); User user = userRepository.findByName(getUsername()); if(user != null) { user.clearGroups(); try { addADGroupsToUser(ldapContext, user, userCN); } catch(Exception e) { e.printStackTrace(); } } } protected void addADGroupsToUser(LdapContext ldapContext, User user, String userCN) throws NamingException { SearchControls searchControls = new SearchControls(); searchControls.setReturningAttributes(new String[] { "dn"}); searchControls.setSearchScope(searchScope); GroupRepository groupRepository = GlobalContext.locate(GroupRepository.class); String filterGroups = "(&(member="+userCN+","+searchBase+")(objectcategory=group))"; NamingEnumeration results = ldapContext.search(searchBase, filterGroups, searchControls); while (hasMoreResults(results)) { String groupCN = ((SearchResult) results.next()).getName(); Group group = groupRepository.findByCN(groupCN); if(group!=null) { user.addGroup(group); } } } private boolean hasMoreResults(NamingEnumeration ne) { try { return ne.hasMore(); } catch (NamingException e) { return false; } } @Override protected boolean verifyLoginData() throws FailedLoginException { String[] returnedAtts = {authenticationAttribute}; Encoder encoder = DefaultEncoder.getInstance(); String requestSearchFilter = searchFilter.replaceAll("%USERNAME%", encoder.encodeForLDAP(getUsername())); SearchControls searchCtls = new SearchControls(); searchCtls.setReturningAttributes(returnedAtts); searchCtls.setSearchScope(searchScope); Hashtable<String, String> env = getEnv(); debug("Verifying credentials for user: " + getUsername()); boolean ldapUser = false; String userCN = null; try { LdapContext ldapContext = getLdapContext(env); if (ldapContext != null) { NamingEnumeration<SearchResult> answer = ldapContext.search(searchBase, requestSearchFilter, searchCtls); while (!ldapUser && answer.hasMoreElements()) { SearchResult sr = answer.next(); userCN = sr.getName(); Attributes attrs = sr.getAttributes(); if (attrs != null) { NamingEnumeration<? extends Attribute> ne = attrs.getAll(); ldapUser = ne.hasMore(); ne.close(); } } debug("Authentication succeeded"); if(Boolean.TRUE.equals(GlobalContext.locate(ConfigurationService.class).getValue(SimbaConfigurationParameter.ENABLE_AD_GROUPS)) && userCN != null) { updateUserGroups(ldapContext, userCN); } } return ldapUser; } catch (NamingException ex) { debug("Authentication failed"); throw new FailedLoginException(ex.getMessage()); } } private Hashtable<String, String> getEnv() { Hashtable<String, String> env = new Hashtable<String, String>(); env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory"); if (securityLevel != null) { env.put(Context.SECURITY_AUTHENTICATION, securityLevel); } env.put(Context.SECURITY_PRINCIPAL, getUsername() + "@" + authenticationDomain); env.put(Context.SECURITY_CREDENTIALS, getPassword()); return env; } private LdapContext getLdapContext(Hashtable<String, String> env) { LdapContext ldapContext = tryPrimaryContext(env); if (ldapContext == null && secondaryServerHost != null) { ldapContext = trySecondaryContext(env); } return ldapContext; } @Override protected void getLoginDataFromUser() throws LoginException { try { getCallbackHandler().handle(callbacks); setUsername(getNameFromCallback()); setPassword(getPasswordFromCallback()); resetPassword(); } catch (java.io.IOException ioe) { throw new LoginException(ioe.toString()); } catch (UnsupportedCallbackException uce) { throw new LoginException("Callback error : " + uce.getCallback().toString() + " not available to authenticate the user"); } } private void resetPassword() { ((PasswordCallback) callbacks[PASSWORD_CALLBACK]).clearPassword(); } private String getPasswordFromCallback() { return String.valueOf(((PasswordCallback) callbacks[PASSWORD_CALLBACK]).getPassword()); } private String getNameFromCallback() { return ((NameCallback) callbacks[NAME_CALLBACK]).getName(); } protected void setCallBacks(Callback[] callbacks) { this.callbacks = callbacks; } protected LdapContext tryPrimaryContext(Hashtable<String, String> env) { env.put(Context.PROVIDER_URL, "ldap://" + primaryServerHost + ":" + primaryServerPort); try { return new InitialLdapContext(env, null); } catch (NamingException e) { debug("Authentication against primary server failed..."); return null; } } protected LdapContext trySecondaryContext(Hashtable<String, String> env) { debug("Trying secondary server..."); env.put(Context.PROVIDER_URL, "ldap://" + secondaryServerHost + ":" + secondaryServerPort); try { return new InitialLdapContext(env, null); } catch (NamingException e) { debug("Authentication against secondary server failed..."); return null; } } }